feat(query-orchestrator): evaluate interval refresh keys from local time - #11614
feat(query-orchestrator): evaluate interval refresh keys from local time#11614ovr wants to merge 12 commits into
Conversation
|
Claude finished @ovr's task in 7m 3s —— View job Review complete — solid, well-scoped change. 1 medium, 5 low/nit (posted inline).Full reviewTodos
What I verifiedThe core refactor holds up. The Opt-out coverage is correct at all four emit sites: The floor-arithmetic claim in
I also confirmed Findings
Smaller observations (not worth inline threads)
PraiseThe Security: nothing to flag. The descriptor is compiler-produced numbers, validated before use, and never reaches SQL. |
| interval: number; | ||
| utcOffset: number; | ||
| dayOffset: number; | ||
| cron?: boolean; |
There was a problem hiding this comment.
Nit: cron is never read — neither evaluateLocalRefreshKey nor isValidLocalRefreshKey looks at it, and everyRefreshKeyParts only uses it internally in everyRefreshKeySql to pick the branch. It rides along in every serialized invalidateKeyQueries tuple for no consumer. Either drop it from the descriptor (keep it as a local in everyRefreshKeySql) or add a comment saying it is retained deliberately for debuggability.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #11614 +/- ##
===========================================
- Coverage 79.91% 58.87% -21.04%
===========================================
Files 498 237 -261
Lines 104694 18982 -85712
Branches 3841 3850 +9
===========================================
- Hits 83661 11175 -72486
+ Misses 20472 7246 -13226
Partials 561 561
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
03e0d9f to
1b3b5a8
Compare
|
Claude finished @ovr's task in 2m 49s —— View job Re-review after rebase — 1 medium (re-anchored), 4 low/nit still open. No new defects found.Full reviewTodos
What changed since the last reviewThe branch was rebased onto master with #11607 (
Re-verification of the parts that didn't changeThe Return shape matches the SQL path ( Security: nothing to flag. The descriptor is compiler-produced numbers, validated by Open findings
Findings 2–5 are unchanged in the current diff, so I did not re-post them; the existing unresolved threads still carry them. Finding 1's old thread went outdated when the line moved, so I resolved it and re-anchored the comment at On finding 1 specifically: the commit message now documents the one-time rebuild and the clock-skew blast radius thoroughly, but the Smaller observations
PraiseThe |
…ime behind a flag Interval and cron based `refreshKey` values can now be computed from the API instance clock instead of via `SELECT FLOOR(...) as refresh_key`, gated by `CUBEJS_REFRESH_KEY_LOCAL_TIME` (default `false`). The compiler already derived every input in JS — `interval`, `dayOffset` and `utcOffset` come from `parseSecondDuration`/`calcIntervalForCronString`. SQL only contributed `now()`. So `BaseQuery.everyRefreshKeyParts()` is extracted as the single source of truth that both the rendered SQL and a serializable descriptor derive from, and when the flag is on that descriptor rides in the refresh-key tuple's options element. The orchestrator short-circuits it in exactly one place, `QueryCache.cacheRefreshKeyResult`. #11607 had just made that the sole owner of a refresh key's cache key, renewal key and threshold, so both consumers — the scheduler's `loadRefreshKey` and the loader's `keyQueryResult` — are covered without either being touched. `PreAggregationLoadCache` keeps its per-request memo wrapping the call, which matters: one pre-aggregation load reads the invalidation keys several times (`contentVersion`, the returned `refreshKeyValues`, the refresh queue key), and re-reading the clock could straddle an interval boundary and have the load look a table up under one content version while enqueueing it under another. A test pins that by stubbing `Date.now` across a boundary. Why: the default `{ every: '10 seconds' }` cube refresh key has `renewalThreshold: 10`, so it is re-issued to Cube Store on essentially every request. That query has no `TableScan`, so `is_data_select_query` is false and it takes the `QueryPlan::Meta` path — correctly bypassing `SqlResultCache`, but still paying parser + plan + optimize + `collect`, plus `MetaStoreSchemaProvider::new(get_tables_with_path(false))` on every call, plus a WebSocket round trip. All to learn the wall clock. Scope: cube-level `cacheKeyQueries` and pre-aggregation `invalidateKeyQueries`, including the `10 seconds` and `1 hour` defaults. Excluded are `refreshKey.sql` and `incremental` keys — the latter are wrapped in `CASE WHEN NOW() < <dateTo + updateWindow>` against an allocated partition-range param, and their options drive `renewalThresholdOutsideUpdateWindow` shortening for freshly sealed partitions. Flag off is byte-identical. The gate is applied at emit time as well as consume time, so the tuples and every hash derived from them are unchanged and no persisted cache is invalidated on upgrade. The existing `everyRefreshKeySql` assertions pass unedited, which is what proves the emitted SQL did not move. Clock skew is why this is flagged. Two nodes straddling an interval boundary produce different `contentVersion`s, so the same pre-aggregation gets built twice, recurring at each boundary. `externalRefresh` bounds the blast radius — non-builder API instances never run these queries — so a single refresh worker is safe; multiple workers or `CUBEJS_PRE_AGGREGATIONS_BUILDER=true` API instances are not. Flipping the flag also forces one pre-aggregation rebuild each way, since `pg` returns `numeric` as a string and Cube Store as a number. Table names are unaffected; `getStructureVersion` excludes invalidation keys. Three things review caught, all fixed here: - `refreshKeyRenewalThreshold` would have stopped working. On the SQL path that option throttles how often the result is re-read, and that is also what bounds how often the key advances — a value cached for a day advances daily, whatever `every` says. A locally evaluated key has no cache entry to age out, so `refreshKeyRenewalThreshold: 86400` with `every: '1 hour'` would have gone from ~daily to hourly advancement, roughly 24x the pre-aggregation rebuilds. Local evaluation now declines when the override is set, and says so once in the log rather than silently doing nothing. - The flag was wired in two halves. `QueryCache` accepted a `localRefreshKey` option while the emitting half read only the environment, and `QueryOrchestrator.queryCacheOptions` is typed `any`, so `queryCacheOptions.localRefreshKey = true` type-checked and enabled the consumer while no descriptor was ever emitted — a silent no-op. The consumer now reads the environment only, which makes the half-enabled state unrepresentable. Tests toggle `process.env.CUBEJS_REFRESH_KEY_LOCAL_TIME` around construction instead. - `RefreshScheduler.refreshCubesRefreshKey` exists only to warm the shared refresh-key cache, so with the flag on it spent a `getSql` plus an `executeQuery` per cube per timezone on a result that is computed locally and thrown away. It now skips a cube whose key is locally evaluable. Note it skips per cube rather than wholesale: the flag does not make every key local, and a cube with a `sql` refresh key still needs warming. `EvaluatedCube` gained the `refreshKey` field it already carried at runtime, so the scheduler can narrow on it without a cast. | Suite | Result | | --- | --- | | `cubejs-backend-shared` `test/env.test.ts` | 25/25 | | `cubejs-query-orchestrator` `test/unit` | 130/130, 6 suites | | `cubejs-schema-compiler` `dist/test/unit` | 826/831, 39/42 suites | `server-core` passes `tsc --noEmit`. The five schema-compiler failures are `error-reporter`, `FILTER_PARAMS` and `pre-agg-interpolated-cube-refs`; all five reproduce on an unmodified master, verified by reverting this branch's two schema-compiler files and re-running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1b3b5a8 to
65e759d
Compare
|
Claude finished @ovr's task in 3m 39s —— View job Re-review after the latest push — 1 medium (new), 1 low (new), 3 low/nit still open. Prior medium resolved.Full reviewTodos
What changed since the last reviewThree material changes, all in response to the earlier round:
Re-verification of the unchanged parts
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by I could not run the test suites in this job (the sandbox declined the Findings
Findings 3–5 are unchanged in the current diff, so I did not re-post them; the existing unresolved threads carry them. I also left the Smaller observations
PraiseThe |
Lead with what Cube does today before what the flag changes, explain the `sql`/`incremental` exceptions by their cause rather than by the implementation, and document that a configured `queryCacheOptions.refreshKeyRenewalThreshold` keeps refresh keys on the SQL path. Drop the env.ts docblock now that the explanation lives in the docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 2m 20s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| A | Low | environment-variables.mdx:1437 |
New — the sql/incremental exception is explained by one reason that's only true of sql; an incremental key is time arithmetic, excluded because it's wrapped in CASE WHEN NOW() < <dateTo> against a partition-range param |
| B | Low | environment-variables.mdx:1449 |
Re-anchored — one-time full pre-agg rebuild on toggle (string → number refresh_key) and fleet-synchronized invalidation, still undocumented |
Carried over, unchanged in this diff, existing threads still hold them:
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | Medium | RefreshScheduler.ts:359 |
Still open — skip predicate diverges from the gate that decides local evaluation; a deployment with refreshKeyRenewalThreshold set loses warming while keys still run as queries |
| 2 | Low | QueryCache.ts:72 |
Still open — cron field on the descriptor has no consumer |
| 3 | Nit | QueryCache.abstract.ts:538 |
Still open — expect(...).toEqual([{ refresh_key: Math.floor(Date.now() / ...) }]) reads the clock after the call, so it flakes on an interval boundary |
| 4 | Nit | QueryCache.ts:271 |
Still open — logLocalRefreshKeyOnce comment cites an initialization-order hazard that doesn't exist (logger is a parameter property) |
Resolved two of my own threads: the refreshKeyRenewalThreshold docs ask at QueryCache.ts:252 (the alternative I offered was "quantize, or say so in the docs" — this push says so), and the outdated docs thread, re-anchored as finding B above.
Smaller observations (unchanged, not worth threads)
base-query.test.ts'emits no descriptor when the flag is off'constructsPostgresQuerywithoutlocalRefreshKeyandBaseQuery.js:367falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. The orchestrator tests were hardened against exactly this leak; the compiler test still has it.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally, so a failure leaks'yes'into later tests. Matches the surrounding file's style.- Still no end-to-end test of a cron-based key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable, so the one most worth an integration assertion.
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Praise
Rewriting the env var section to explain the mechanism before the switch, and to name the refreshKeyRenewalThreshold interaction outright, is the version an operator can actually act on — the earlier draft described the implementation, this one describes the decision. The everyRefreshKeyParts agrees with the SQL it renders test and the keyQueryResult is stable across an interval boundary within one load cache test remain the two assertions carrying this PR's safety argument, and both are untouched.
Same facts, roughly half the prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 35s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| A | Low | environment-variables.mdx:1430-1432 |
Re-anchored — "their values depend on your data" is true of sql, not of incremental; an incremental key is time arithmetic wrapped in CASE WHEN NOW() < <dateTo> against a partition-range param, so it depends on which partition is checked |
| B | Low | environment-variables.mdx:1439-1442 |
Re-anchored — one-time full pre-agg rebuild on toggle (driver string → JS number changes every contentVersion) and fleet-synchronized invalidation, still undocumented |
Carried over, unchanged in this diff, existing threads still hold them:
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | Medium | RefreshScheduler.ts:359 |
Still open — the skip predicate (getEnv('refreshKeyLocalTime') + no sql key) diverges from the gate that actually decides local evaluation; a deployment with refreshKeyRenewalThreshold set loses scheduled warming while its keys still run as queries. Note this push makes the divergence more visible, since the docs now say plainly that such a deployment keeps querying the database |
| 2 | Low | QueryCache.ts:72 |
Still open — cron field on the descriptor has no consumer |
| 3 | Nit | QueryCache.abstract.ts:538 |
Still open — expect(...).toEqual([{ refresh_key: Math.floor(Date.now() / ...) }]) reads the clock after the call, so it flakes on an interval boundary |
| 4 | Nit | QueryCache.ts:271 |
Still open — logLocalRefreshKeyOnce comment cites an initialization-order hazard that doesn't exist (logger is a parameter property) |
Smaller observations (unchanged, not worth threads)
config.mdx's ownorchestrator_optionsexample setsrefreshKeyRenewalThreshold: 30. Anyone who copied that example verbatim now has this flag silently disabled — the docs are at least explicit about it as of this push, but it's an argument for quantizing rather than opting out.base-query.test.ts'emits no descriptor when the flag is off'constructsPostgresQuerywithoutlocalRefreshKeyandBaseQuery.js:367falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. The orchestrator tests were hardened against exactly this leak; the compiler test still has it.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally, so a failure leaks'yes'into later tests. Matches the surrounding file's style.- Still no end-to-end test of a cron-based key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable, so the one most worth an integration assertion.
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Praise
Three docs passes in and this one is the version that earns its space: shorter than the last draft and clearer about the one thing an operator can get wrong, because "so do all refresh keys if you have set refreshKeyRenewalThreshold" names the scope instead of gesturing at it. Cutting the SELECT FLOOR(...) explanation was the right thing to cut — it was there for reviewers, not readers. The everyRefreshKeyParts agrees with the SQL it renders test and the keyQueryResult is stable across an interval boundary within one load cache test remain the two assertions carrying this PR's safety argument, and both are untouched.
| Refresh keys written with `sql` or marked | ||
| [`incremental`](/reference/data-modeling/pre-aggregations#incremental) keep querying the | ||
| database, since their values depend on your data and not just on the time. So do all |
There was a problem hiding this comment.
Re-anchored — the tightening in 8956c9a moved this sentence, so the old thread went outdated; the concern is unchanged, and the shorter phrasing actually sharpens it.
"their values depend on your data and not just on the time" is right for sql but not for incremental. An incremental refresh key is time arithmetic — it starts from the same every interval and is then wrapped in CASE WHEN NOW() < <dateTo + updateWindow> against an allocated partition-range parameter (BaseQuery.js:5088-5101; the code comment there says as much). Its value doesn't depend on your data at all; it depends on which partition is being checked, which the API instance can't reproduce from an interval alone.
As written, someone with an incremental rollup will conclude their refresh key reads the fact table on every check. Giving each its own reason keeps the plain language and stays true, e.g. "…keep querying the database — a sql key's value comes from your data, and an incremental key's depends on which partition is being checked."
Continues the comment trim on the local refresh key path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 11s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | Medium | RefreshScheduler.ts:359 |
Still open — the skip predicate (getEnv('refreshKeyLocalTime') + no sql key) diverges from the gate that actually decides local evaluation (QueryCache.localRefreshKeyResult also declines when refreshKeyRenewalThreshold is set, or on a malformed descriptor). Such a deployment loses scheduled warming while its keys still run as queries |
| 2 | Low | docs-mintlify/reference/configuration/environment-variables.mdx:1432 |
Still open — "their values depend on your data" is true of sql, not of incremental |
| 3 | Low | environment-variables.mdx:1442 |
Still open — one-time full pre-agg rebuild on toggle (driver string → JS number changes every contentVersion) and fleet-synchronized invalidation, still undocumented |
| 4 | Low | QueryCache.ts:68 |
Still open — cron field on the descriptor has no consumer. Note this thread is now correctly anchored again after the 4-line shift |
| 5 | Nit | QueryCache.abstract.ts:538 |
Still open — expect(...).toEqual([{ refresh_key: Math.floor(Date.now() / ...) }]) reads the clock after the call, so it flakes on an interval boundary |
| 6 | Nit | QueryCache.ts:266 |
Still open — logLocalRefreshKeyOnce comment cites an initialization-order hazard that doesn't exist (logger is a parameter property) |
Nothing to resolve this round: every unresolved thread of mine is still applicable, and the already-resolved ones stayed resolved. Finding 6's comment moved by 4 lines but the thread is still anchored on it, so no re-anchor needed either.
Smaller observations (unchanged, not worth threads)
config.mdx's ownorchestrator_optionsexample setsrefreshKeyRenewalThreshold: 30. Anyone who copied it verbatim now has this flag silently disabled — the docs are explicit about it as of8956c9a, but it remains an argument for quantizing rather than opting out.base-query.test.ts'emits no descriptor when the flag is off'constructsPostgresQuerywithoutlocalRefreshKeyandBaseQuery.js:367falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. The orchestrator tests were hardened against exactly this leak; the compiler test still has it.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally, so a failure leaks'yes'into later tests. Matches the surrounding file's style.- Still no end-to-end test of a cron-based key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable.
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Praise
Five pushes in, the diff has only gotten easier to review — this one is four lines of prose, which costs a reviewer nothing to confirm. The everyRefreshKeyParts agrees with the SQL it renders test and the keyQueryResult is stable across an interval boundary within one load cache test remain the two assertions carrying this PR's safety argument, and both are untouched.
…ation is vetoed
`refreshCubesRefreshKey` decided on its own, from `getEnv('refreshKeyLocalTime')`,
that a cube whose `refreshKey` has no `sql` needs no warming. The real gate is
`QueryCache.localRefreshKeyResult`, which also requires
`queryCacheOptions.refreshKeyRenewalThreshold` to be unset — with the threshold
configured those keys still run as queries against the data source. So a deployment
that set both lost scheduled warming for keys that were still being queried, and
paid the round trip on the request path instead.
The predicate now asks the cache instance that will serve those keys
(`isLocalRefreshKeyActive()`) rather than the environment. That also fixes two
smaller mismatches: the flag is snapshotted in the `QueryCache` constructor while
the scheduler re-read it per run, and `queryCacheOptions` is per tenant while the
env var is global.
`getOrchestratorApi` moves out of the per-timezone loop, since it is now needed
before it.
| flag | `refreshKeyRenewalThreshold` | interval key runs as query | warmed by scheduler |
| ---- | ---------------------------- | -------------------------- | ------------------- |
| off | any | yes | yes |
| on | unset | no | no |
| on | set | yes | yes (was: no) |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`evaluateLocalRefreshKey` returned a JS number while the SQL path it replaces
returns a string, and both `contentVersion` (`PreAggregationLoader.contentVersion`
-> `version()` = md5 of `JSON.stringify`) and the query cache `renewalKey` hash
those values. So `496718` and `"496718"` were different keys and every
pre-aggregation rebuilt the first time the flag was turned on.
String is what the SQL path yields, not an arbitrary pick: `everyRefreshKeySql`
delegates to `this.externalQuery()` whenever an external store is configured, so
an `every` key executes against Cube Store — whose HTTP protocol has no numeric
type at all (`TableValue::Int(v) => builder.create_string(&v.to_string())`).
`RefreshScheduler` already declared `refreshKeyValues: {refresh_key: string}[][]`,
and the driver mock in `QueryCache.abstract.ts` already returned `'1'`.
Verified live on test-postgres against a cold Cube Store, same partition:
| flag | targetTableName |
| ---- | --------------- |
| off | `rk_preagg_by_status20230101_5rz2zhq_ajmwphuf_…` |
| on (before) | `rk_preagg_by_status20230101_tz5tugxu_ajmwphuf_…` |
| on (after) | `rk_preagg_by_status20230101_5rz2zhq_ajmwphuf_…` |
Nothing compares these values numerically, so the change is confined to their
representation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page claimed the flag saves "a round trip on nearly every request". It does not: the refresh key result is itself cached for `refreshKeyRenewalThreshold` (roughly `every/10`), so the query runs once per renewal window regardless of traffic. Measured on test-postgres with 30 `must-revalidate` requests over ~34s against a cube on the default 10 second refresh key: | | flag off | flag on | | --- | --- | --- | | refresh key queries | 5 | 0 | | data queries | 5 | 6 | | p50 latency | 136 ms | 137 ms | So the saving scales with cubes x tenants x timezones, not with request rate, and it is a query-count win rather than a latency win. Also notes that scheduled refresh stops warming these keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 4m 4s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| — | Medium | RefreshScheduler.ts |
Resolved — shared predicate + test |
| 1 | Low | environment-variables.mdx:1446 |
Narrowed & re-anchored — one-time rebuild now only affects deployments with no external store on a number-returning driver; fleet-synchronized invalidation still undocumented |
| 2 | Low | environment-variables.mdx:1436 |
Still open — "their values depend on your data" is true of sql, not of incremental |
| 3 | Nit | QueryCache.abstract.ts:538 |
Re-anchored (the line was touched by 4f5f6d2) — assertion reads the clock after the call, so it flakes on an interval boundary; same at PreAggregations.test.ts:534 |
| 4 | Nit | QueryCache.ts:68 |
Still open — cron field on the descriptor has no consumer |
| 5 | Nit | QueryCache.ts:269 |
Still open — logLocalRefreshKeyOnce comment cites an initialization-order hazard that doesn't exist (logger is a parameter property) |
Findings 2, 4 and 5 are unchanged in this diff, so I did not re-post them; the unresolved threads carry them. Resolved three of my own: the RefreshScheduler medium (fixed), the old docs rebuild thread (its central claim — "returns a JS number" — is now false), and the old outdated flake thread (re-anchored).
Smaller observations (not worth threads)
base-query.test.ts'emits no descriptor when the flag is off'constructsPostgresQuerywithoutlocalRefreshKeyandBaseQuery.js:367falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. The newRefreshSchedulertests do the right thing here (delete process.env.…inbeforeEach); the compiler test still has the leak.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally.- Every cron case in
base-query.test.tspassesrefreshKey.timezoneexplicitly, so theutcOffset: NaNbranch thatisValidLocalRefreshKeyexists to catch has no test. Still the branch most worth an integration assertion, now with a second reason: it's also the branch where theRefreshSchedulergate and theQueryCachegate can disagree.
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Praise
isLocalRefreshKeyActive() is the small right answer to that finding — one predicate, named, tested, and consulted from both sides, rather than a second copy of the condition in the scheduler. And 4f5f6d2 is the kind of fix that only comes from taking a review note seriously and then digging past it: the string return isn't just "avoid the rebuild", it's a demonstration that the SQL path's own shape was a string all along, right down to the type annotation already sitting in RefreshScheduler. The everyRefreshKeyParts agrees with the SQL it renders and keyQueryResult is stable across an interval boundary tests still carry the safety argument, and both survived intact.
| Cube now trusts each machine's clock, so they all have to agree. Set this variable to the | ||
| same value on **every** API instance and refresh worker and keep them synchronized with | ||
| NTP. Instances on opposite sides of an interval boundary compute different refresh keys, | ||
| which can build the same pre-aggregation twice. |
There was a problem hiding this comment.
Narrowed from the previous docs thread (resolved — 4f5f6d2 returning a string removes most of what it warned about). Two things about the toggle are still worth a sentence here:
-
The one-time rebuild isn't fully gone, only narrowed.
String(...)matches what Cube Store returns, and Cube Store is where aneverykey runs wheneverexternalQueryClassis set (BaseQuery.js:4896) — which is the usual deployment, so the common case is now byte-identical. It doesn't match a deployment with no external store, where the key runs against the source DB:pghands backnumericas a string (fine), butFLOORin BigQuery isFLOAT64and comes back as a JS number, socontentVersionstill moves once on the toggle there. Worth one clause rather than a paragraph, since it's now the minority case. -
Invalidation becomes fleet-synchronized (unchanged, still undocumented). Today each node caches its refresh-key result independently, so when a node notices a new interval is staggered by whenever it last fetched. With clocks in sync every node flips at the same instant, so primary-query caches across the fleet miss simultaneously at each boundary. Not incorrect — but it's a thundering-herd shape, and it's the flip side of the clock-sync requirement this warning already asks for, so this is the natural place for it.
| }); | ||
|
|
||
| expect(executed).toBe(0); | ||
| expect(result).toEqual([{ refresh_key: String(Math.floor(Date.now() / 1000 / 600)) }]); |
There was a problem hiding this comment.
Nit (re-anchored — this line was touched by 4f5f6d2, and the old thread had gone outdated/hidden; concern unchanged): the expectation reads the clock after the call, so it fails whenever the test happens to straddle a 600-second boundary. Rare, but it's the kind of flake that shows up once a month in CI and costs an hour to diagnose. Same pattern at PreAggregations.test.ts:534.
jest.spyOn(Date, 'now').mockReturnValue(...) — which the boundary-stability test in PreAggregations.test.ts already uses — removes the race entirely.
Drop the one-shot warning logged when `refreshKeyRenewalThreshold` vetoes local
evaluation — falling back to the SQL path is the intended behaviour, not an anomaly
worth a line on the refresh key path. A TODO records that the two should work
together by snapping the local value to the threshold.
`localRefreshKey` is now a `QueryCacheOptions` field resolved as
`options.localRefreshKey ?? getEnv('refreshKeyLocalTime')`, the same precedence
BaseQuery already uses, so tests no longer have to mutate and restore
CUBEJS_REFRESH_KEY_LOCAL_TIME around construction.
Unit tests cover `localRefreshKeyResult` directly: a valid descriptor, the string
typed result Cube Store returns for a SQL key, and every declined branch (flag off,
flag unset, no descriptor, malformed descriptor, threshold configured), each
asserting nothing is logged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 2m 49s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | High | QueryCache.abstract.ts:477-492 |
New — newCache's localRefreshKey parameter is unused; line 488 hardcodes localRefreshKey: undefined. Every test in the local refresh key block runs with the flag off, so the four enabled-path assertions should now fail, and nothing exercises local evaluation in this file |
| 2 | Low | QueryCache.ts:231 |
New — the option can enable the consumer while the emitter (env-only, server.ts:741) stays off: no descriptors emitted, keys still run as queries, and isLocalRefreshKeyActive() returns true so RefreshScheduler skips their warming. That's the 2131ec4 regression re-entering through the option. Narrow but reachable via the unvalidated function form of orchestratorOptions |
| 3 | Low | environment-variables.mdx:1436 |
Still open — "their values depend on your data" is true of sql, not of incremental |
| 4 | Low | environment-variables.mdx:1446 |
Still open — one-time rebuild on the no-external-store path, and fleet-synchronized invalidation |
| 5 | Nit | QueryCache.ts:68 |
Still open — cron field on the descriptor has no consumer |
| 6 | Nit | QueryCache.abstract.ts:527 |
Still open — assertion reads the clock after the call, so it flakes on an interval boundary. The two new localRefreshKeyResult tests (640, 645-650) add the same pattern |
Findings 3–6 are unchanged in this diff, so I did not re-post them; the unresolved threads carry them. Resolved one thread (the logLocalRefreshKeyOnce comment nit, now moot).
On finding 1 specifically: this is the second time in this PR that a test-seam change quietly disabled the thing under test, and the tests are the entire safety argument here. Worth running yarn unit in packages/cubejs-query-orchestrator before merge — I could not run it in this job (the sandbox declined every npx jest invocation), so the failure prediction above is from reading the code, not from a red run. The reasoning is mechanical, though: the parameter is never referenced inside the constructed options object, and cacheOptions only ever carries refreshKeyRenewalThreshold.
Re-verification of the unchanged parts
everyRefreshKeyParts / everyRefreshKeySql, localRefreshKeyResult's decline branches, utils.ts, PreAggregationLoadCache.keyQueryResult's memo and the RefreshScheduler gate are otherwise byte-identical to 151b09b. isLocalRefreshKeyActive() is still the single predicate consulted by both localRefreshKeyResult and RefreshScheduler.refreshCubesRefreshKey (RefreshScheduler.ts:346-350), so the fix from 2131ec4 holds for the env-driven configuration. Return shape is still [{ refresh_key: string }].
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Smaller observations (not worth threads)
- The new
does not report the declined local evaluation when …cases assert the absence of a log line that no code emits any more. Harmless as a regression guard, but note it currently passes trivially — and, per finding 1, for two of the three cases the flag isn't even set. base-query.test.ts'emits no descriptor when the flag is off'still falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. Now that the orchestrator side has a real option again, the compiler test could pinlocalRefreshKey: falsethe same way.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally.- Still no end-to-end test of a cron key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable.
Praise
The TODO on the threshold opt-out is the honest way to close that discussion: the conservative behaviour ships, the better behaviour is named in the one place someone would look. And splitting localRefreshKeyResult out into direct unit tests — valid, missing, malformed, threshold-vetoed, string-typed — is a better shape than only asserting through loadRefreshKeys; it will be genuinely useful once the helper actually applies the flag.
| this.memoryCache = new LRUCache<string, CacheEntry>({ | ||
| max: options.maxInMemoryCacheEntries || 10000 | ||
| }); | ||
| this.localRefreshKeyEnabled = options.localRefreshKey ?? getEnv('refreshKeyLocalTime'); |
There was a problem hiding this comment.
Reintroducing the option re-opens the emit/consume split the previous commit closed, and it now has a second-order effect it didn't have before.
The emit side is env-only: server.ts:741 wires localRefreshKey: getEnv('refreshKeyLocalTime') into CompilerApi, with no option path. So a deployment that sets queryCacheOptions.localRefreshKey: true while CUBEJS_REFRESH_KEY_LOCAL_TIME is unset gets:
- no descriptors emitted →
localRefreshKeyResultalways returnsnull→ every interval key still runs as a query, and isLocalRefreshKeyActive()→true, soRefreshScheduler.refreshCubesRefreshKey(RefreshScheduler.ts:359) skips warming those cubes.
That is exactly the regression 2131ec4 fixed, arriving through the new option instead of through the threshold. Net effect is worse than not setting the flag at all.
Reachability is narrow but real: optionsValidate.ts:127 declares queryCacheOptions with .keys({...}) and no localRefreshKey, so the object form is rejected — but orchestratorOptions also accepts Joi.func(), and the function form isn't validated, so orchestratorOptions: () => ({ queryCacheOptions: { localRefreshKey: true } }) passes. Same for anyone constructing QueryOrchestrator directly.
Two ways out, either fine: resolve the flag in server.ts from the same place both halves read (pass the effective value into CompilerApi and queryCacheOptions), or keep the option test-only and have isLocalRefreshKeyActive() not be satisfiable by the option alone. If the option is meant to be user-facing, it also needs a line in the Joi schema.
Also restores `newCache`'s `localRefreshKey` argument: it was pinned to `undefined`, so the four tests that expect local evaluation were exercising the flag-off path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 39s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | Low | QueryCache.ts:231 |
Still open — the option can enable the consumer while the emitter stays off. server.ts:741 is still env-only (localRefreshKey: getEnv('refreshKeyLocalTime')), and isLocalRefreshKeyActive() is satisfied by the option alone, so RefreshScheduler would skip warming keys that are still running as queries |
| 2 | Low | environment-variables.mdx:1436 |
Still open — "their values depend on your data" is true of sql, not of incremental |
| 3 | Low | environment-variables.mdx:1446 |
Still open — one-time rebuild on the no-external-store path, and fleet-synchronized invalidation |
| 4 | Nit | QueryCache.ts:68 |
Still open — cron field on the descriptor has no consumer |
| 5 | Nit | QueryCache.abstract.ts:527 |
Still open — assertions compare against a clock read after the call, so they flake on an interval boundary (also at 634 and 642) |
Resolved one thread this round: the newCache high, now genuinely fixed.
Re-verification of the unchanged parts
everyRefreshKeyParts / everyRefreshKeySql, localRefreshKeyResult's decline branches, isValidLocalRefreshKey, PreAggregationLoadCache.keyQueryResult's memo and the RefreshScheduler gate (RefreshScheduler.ts:347,365, consulting isLocalRefreshKeyActive() through the orchestrator) are byte-identical to a9672ca. Return shape is still [{ refresh_key: string }].
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Smaller observations (unchanged, not worth threads)
- The
does not report the declined local evaluation when …cases assert the absence of a log line no code emits, so they pass trivially. Harmless as a regression guard now that the flag is genuinely applied. base-query.test.ts'emits no descriptor when the flag is off'still falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. Now that the orchestrator tests pin the option explicitly, the compiler test could do the same withlocalRefreshKey: false.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally.- Still no end-to-end test of a cron key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable.
Praise
Turning a one-line test-seam bug into a smaller, cleaner helper — the comment moved above the object literal and the spread does the work — is the right size of fix. And the trimmed evaluateLocalRefreshKey docblock is a good example of shortening without losing the argument: the String(...) rationale survived intact, and that's the sentence a future reader will need.
…he test options The helper took the flag as its own parameter and then hardcoded `localRefreshKey: undefined` in the options it built, so the parameter was shadowed and every cache in the block resolved the flag through `CUBEJS_REFRESH_KEY_LOCAL_TIME`, unset under test. The four flag-on cases passed only because they never reached the local path. `localRefreshKey` is already a `QueryCacheOptions` field, so the parameter was redundant with the options bag the helpers forward: drop it and let callers pass the flag there. One place for the flag to come from means the shadowing cannot come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 51s —— View job Re-review after
|
| # | Severity | Where | Status |
|---|---|---|---|
| 1 | Low | QueryCache.ts:231 |
Still open — the option can enable the consumer while the emitter stays off. server.ts:741 is env-only (localRefreshKey: getEnv('refreshKeyLocalTime')) and isLocalRefreshKeyActive() is satisfied by the option alone, so RefreshScheduler would skip warming keys that are still running as queries |
| 2 | Low | environment-variables.mdx:1436 |
Still open — "their values depend on your data" is true of sql, not of incremental |
| 3 | Low | environment-variables.mdx:1446 |
Still open — one-time rebuild on the no-external-store path, and fleet-synchronized invalidation |
| 4 | Nit | QueryCache.ts:68 |
Still open — cron field on the descriptor has no consumer |
| 5 | Nit | QueryCache.abstract.ts:515 |
Still open — the assertions compare against a clock read after the call (also at 626 and 634, and PreAggregations.test.ts:534), so they flake on a 600-second boundary. This commit touched those lines without pinning Date.now |
Nothing to resolve this round: all five are still applicable, and the previously resolved threads (including the newCache high) stayed resolved and correctly so.
Re-verification of the unchanged parts
everyRefreshKeyParts / everyRefreshKeySql, localRefreshKeyResult's decline branches, isLocalRefreshKeyActive(), isValidLocalRefreshKey, evaluateLocalRefreshKey, PreAggregationLoadCache.keyQueryResult's memo and the RefreshScheduler gate (RefreshScheduler.ts:347,365, consulting isLocalRefreshKeyActive() through the orchestrator) are byte-identical to 13d8373. Return shape is still [{ refresh_key: string }].
Security: nothing to flag. The descriptor is compiler-produced numbers, validated by isValidLocalRefreshKey before use, and never reaches SQL.
Smaller observations (unchanged, not worth threads)
- The
does not report the declined local evaluation when …cases assert the absence of a log line no code emits, so they pass trivially — harmless as a regression guard. base-query.test.ts'emits no descriptor when the flag is off'still constructsPostgresQuerywithoutlocalRefreshKeyandBaseQuery.js:367falls back togetEnv, so it fails ifCUBEJS_REFRESH_KEY_LOCAL_TIME=trueis set ambiently. The orchestrator tests now pin the option explicitly in one consistent place; the compiler test could do the same withlocalRefreshKey: false.env.test.tsrefreshKeyLocalTime(exception)deletes the env var after theexpectrather than in afinally.- Still no end-to-end test of a cron key through
PreAggregationLoadCachewith the flag on — the branch where a non-zerodayOffsetand aNaNutcOffsetare both reachable.
Praise
The right follow-up to the 13d8373 fix, and a strictly smaller API: collapsing two ways of supplying the flag into one removes the class of bug rather than the instance, and the comment about the unset case moved onto the single test that actually exercises it. Nine pushes in, the diff keeps getting cheaper to review.
`QueryCache` resolved the flag as `options.localRefreshKey ?? getEnv('refreshKeyLocalTime')`,
so `undefined` meant "ask the env" and every test that left the option out needed a comment
explaining where the value came from. Nothing in server-core passed the option either, so the
orchestrator's only real source was that env read, three layers below the config.
`OptsHandler.getOrchestratorInitializedOptions` now defaults `queryCacheOptions.localRefreshKey`
from the env the same way it already does for `rollupOnlyMode`, and `QueryCache` treats the
option as authoritative. `undefined` there is plainly off, which is what removes the comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I'll analyze this and get back to you. |

Check List
Description of Changes Made
Adds
CUBEJS_REFRESH_KEY_LOCAL_TIME(defaultfalse). With it on,every-basedrefresh_keyvalues are computed from the API instance's own clock instead of aSELECT FLOOR(...) as refresh_keyround trip — the default{ every: '10 seconds' }key hasrenewalThreshold: 10, so today it is re-issued to Cube Store on nearly every request just to read the wall clock.The compiler already derived every input in JS, so
BaseQuery.everyRefreshKeyParts()becomes the single source of the formula behind both the rendered SQL and a serializable descriptor, which the orchestrator short-circuits in one place (QueryCache.cacheRefreshKeyResult) — covering both the scheduler'sloadRefreshKeyand the loader'skeyQueryResult.refreshKey.sqlandincrementalkeys still run against the database, and local evaluation declines whenrefreshKeyRenewalThresholdis set, since that option throttles how often the key advances and a local key has no cache entry to age out.RefreshScheduleralso skips warming a cube whose key is locally evaluable, per cube rather than wholesale.With the flag off the refresh-key tuples and every hash derived from them are byte-identical, which the unedited
everyRefreshKeySqlassertions prove. It ships behind a flag because clock skew matters: two nodes straddling an interval boundary compute differentcontentVersions and can build the same pre-aggregation twice, so a single refresh worker is safe while multiple builders are not.Tests:
cubejs-backend-shared25/25,cubejs-query-orchestratortest/unit130/130,cubejs-schema-compilerdist/test/unit826/831,cubejs-server-coreclean undertsc --noEmit. The five schema-compiler failures reproduce on unmodified master. Not yet smoke-tested against a live deployment.🤖 Generated with Claude Code